/*
 * SystemJS Core
 *
 * Provides
 * - System.import
 * - System.register support for
 *     live bindings, function hoisting through circular references,
 *     reexports, dynamic import, import.meta.url, top-level await
 * - System.getRegister to get the registration
 * - Symbol.toStringTag support in Module objects
 * - Hookable System.createContext to customize import.meta
 * - System.onload(err, id, deps) handler for tracing / hot-reloading
 *
 * Core comes with no System.prototype.resolve or
 * System.prototype.instantiate implementations
 */
import {
    baseUrl,
    BASE_URL,
    hasSymbol,
    ImportMap,
    IMPORT_MAP,
    resolveAndComposeImportMap,
} from "./common";
import { errMsg } from "./err-msg";
import type { SystemJS } from "./s";
import { resolve } from "./features/resolve";
import { Load } from "./Load";
export const toStringTag = hasSymbol && Symbol.toStringTag;
export const REGISTRY = (hasSymbol ? Symbol() : "@") as any as "@";

export class SystemJSCore {
    [BASE_URL] = baseUrl;
    [REGISTRY]: {
        [key: string]: Load;
    } = {};
    constructor() {}
    resolve = resolve;
    [IMPORT_MAP]: ImportMap = {
        imports: {},
        scopes: {},
        depcache: {},
        integrity: {},
    };
    get importMap() {
        return this[IMPORT_MAP];
    }
    extendImportMap(newMap: ImportMap, newMapUrl: string) {
        return resolveAndComposeImportMap(
            Object.assign(
                {
                    imports: {},
                    scopes: {},
                    depcache: {},
                    integrity: {},
                },
                newMap
            ),
            newMapUrl,
            this[IMPORT_MAP]
        );
    }
    /* ! apply is cover the origin map */
    applyImportMap(map: ImportMap) {
        this[IMPORT_MAP] = Object.assign(
            {
                imports: {},
                scopes: {},
                depcache: {},
                integrity: {},
            },
            map
        );
    }
    prepareImport(id: string, parent?: string) {}
    import(id: string, parentUrl?: string) {
        return Promise.resolve(this.prepareImport(id, parentUrl))
            .then(() => {
                return this.resolve(id, parentUrl);
            })
            .then((id) => {
                /* @ts-ignore */
                let load = getOrCreateLoad(this, id);
                /* @ts-ignore */
                return load.C || topLevelLoad(this, load);
            });
    }
    // Hookable createContext function -> allowing eg custom import meta
    createContext(parentId: string) {
        let loader = this;
        return {
            url: parentId,
            resolve(id: string, parentUrl: string) {
                return Promise.resolve(
                    loader.resolve(id, parentUrl || parentId)
                );
            },
        };
    }
    // onLoad(err, id, deps) provided for tracing / hot-reloading
    onload(...args: any[]) {}

    lastRegister: [Load[], Function] | undefined;
    register(deps: Load[], declare: Function) {
        this.lastRegister = [deps, declare];
    }
    /*
     * getRegister provides the last anonymous System.register call
     */
    getRegister(url?: string) {
        let _lastRegister = this.lastRegister;
        this.lastRegister = undefined;
        return _lastRegister;
    }
}

function loadToId(load: Load) {
    return load.id;
}
function triggerOnload(
    loader: Loader,
    load: Load,
    err: Error | null,
    isErrSource: boolean
) {
    loader.onload(err, load.id, load.d && load.d.map(loadToId), !!isErrSource);

    if (err) throw err;
}
export type Loader = SystemJS;
export function getOrCreateLoad(
    loader: Loader,
    id: string,
    firstParentUrl?: string
) {
    let load = loader[REGISTRY][id];
    if (load) return load;

    let importerSetters: Function[] = [];
    let ns = Object.create(null);
    if (toStringTag)
        Object.defineProperty(ns, toStringTag, { value: "Module" });

    let instantiatePromise = Promise.resolve()
        .then(() => {
            return loader.instantiate(id, firstParentUrl);
        })
        .then(
            (registration) => {
                if (!registration)
                    throw Error(
                        errMsg(
                            2,
                            process.env.SYSTEM_PRODUCTION
                                ? id
                                : "Module " + id + " did not instantiate"
                        )
                    );
                function _export(
                    name:
                        | string
                        | {
                              [key: string]: unknown;
                              __esModule: boolean;
                          },
                    value: unknown
                ) {
                    // note if we have hoisted exports (including reexports)
                    load.h = true;
                    let changed = false;
                    if (typeof name === "string") {
                        if (!(name in ns) || ns[name] !== value) {
                            ns[name] = value;
                            changed = true;
                        }
                    } else {
                        for (let p in name) {
                            let value = name[p];
                            if (!(p in ns) || ns[p] !== value) {
                                ns[p] = value;
                                changed = true;
                            }
                        }

                        if (name && name.__esModule) {
                            ns.__esModule = name.__esModule;
                        }
                    }
                    if (changed)
                        for (let i = 0; i < importerSetters.length; i++) {
                            let setter = importerSetters[i];
                            if (setter) setter(ns);
                        }
                    return value;
                }
                let declared = registration[1](
                    _export,
                    registration[1].length === 2
                        ? {
                              import: function (importId: string) {
                                  return loader.import(importId, id);
                              },
                              meta: loader.createContext(id),
                          }
                        : undefined
                );
                load.e = declared.execute || function () {};
                return [registration[0], declared.setters || []];
            },
            (err: Error) => {
                load.e = null;
                load.er = err;
                if (!process.env.SYSTEM_PRODUCTION)
                    triggerOnload(loader, load, err, true);
                throw err;
            }
        );

    let linkPromise = instantiatePromise.then(function (instantiation) {
        return Promise.all(
            instantiation[0].map(function (dep: string, i: number | string) {
                let setter = instantiation[1][i];
                return Promise.resolve(loader.resolve(dep, id)).then(function (
                    depId
                ) {
                    let depLoad = getOrCreateLoad(loader, depId!, id);
                    // depLoad.I may be undefined for already-evaluated
                    return Promise.resolve(depLoad.I).then(function () {
                        if (setter) {
                            depLoad.i.push(setter);
                            // only run early setters when there are hoisted exports of that module
                            // the timing works here as pending hoisted export calls will trigger through importerSetters
                            if (depLoad.h || !depLoad.I) setter(depLoad.n);
                        }
                        return depLoad;
                    });
                });
            })
        ).then(function (depLoads) {
            load.d = depLoads;
        });
    });

    // Capital letter = a promise function
    return (load = loader[REGISTRY][id] =
        {
            id: id,
            // importerSetters, the setters functions registered to this dependency
            // we retain this to add more later
            i: importerSetters,
            // module namespace object
            n: ns,

            // instantiate
            I: instantiatePromise,
            // link
            L: linkPromise,
            // whether it has hoisted exports
            h: false,

            // On instantiate completion we have populated:
            // dependency load records
            d: undefined,
            // execution function
            e: undefined,

            // On execution we have populated:
            // the execution error if any
            er: undefined,
            // in the case of TLA, the execution promise
            E: undefined,

            // On execution, L, I, E cleared

            // Promise for top-level completion
            C: undefined,

            // parent instantiator / executor
            p: undefined,
        });
}

async function instantiateAll(
    loader: Loader,
    load: Load,
    parent: Load,
    loaded: {
        [key: string]: boolean;
    }
): Promise<unknown[] | undefined> {
    if (!loaded[load.id]) {
        loaded[load.id] = true;
        // load.L may be undefined for already-instantiated
        return Promise.resolve(load.L)
            .then(() => {
                if (!load.p || load.p.e === null) load.p = parent;
                return Promise.all(
                    load.d!.map((dep) => {
                        return instantiateAll(loader, dep, parent, loaded);
                    })
                );
            })
            .catch(function (err) {
                if (load.er) throw err;
                load.e = null;
                if (!process.env.SYSTEM_PRODUCTION)
                    triggerOnload(loader, load, err, false);
                throw err;
            });
    }
}

function topLevelLoad(loader: Loader, load: Load) {
    return (load.C = instantiateAll(loader, load, load, {})
        .then(function () {
            return postOrderExec(loader, load, {});
        })
        .then(function () {
            return load.n;
        }));
}

// the closest we can get to call(undefined)
let nullContext = Object.freeze(Object.create(null));

// returns a promise if and only if a top-level await subgraph
// throws on sync errors
function postOrderExec(
    loader: Loader,
    load: Load,
    seen: {
        [key: string]: unknown;
    }
) {
    if (seen[load.id]) return;
    seen[load.id] = true;

    if (!load.e) {
        if (load.er) throw load.er;
        if (load.E) return load.E;
        return;
    }

    // From here we're about to execute the load.
    // Because the execution may be async, we pop the `load.e` first.
    // So `load.e === null` always means the load has been executed or is executing.
    // To inspect the state:
    // - If `load.er` is truthy, the execution has threw or has been rejected;
    // - otherwise, either the `load.E` is a promise, means it's under async execution, or
    // - the `load.E` is null, means the load has completed the execution or has been async resolved.
    const exec = load.e;
    load.e = null;

    // deps execute first, unless circular
    let depLoadPromises!: unknown[] | undefined;
    load.d!.forEach(function (depLoad) {
        try {
            let depLoadPromise = postOrderExec(loader, depLoad, seen);
            if (depLoadPromise)
                (depLoadPromises = depLoadPromises || []).push(depLoadPromise);
        } catch (err) {
            load.er = err as Error;
            if (!process.env.SYSTEM_PRODUCTION)
                triggerOnload(loader, load, err as Error, false);
            throw err;
        }
    });
    if (depLoadPromises) return Promise.all(depLoadPromises).then(doExec);

    return doExec();

    function doExec() {
        try {
            let execPromise: Promise<unknown> = exec.call(nullContext);
            if (execPromise) {
                execPromise = execPromise.then(
                    () => {
                        load.C = load.n;
                        load.E = null; // indicates completion
                        if (!process.env.SYSTEM_PRODUCTION)
                            triggerOnload(loader, load, null, true);
                    },
                    (err: Error) => {
                        load.er = err;
                        load.E = null;
                        if (!process.env.SYSTEM_PRODUCTION)
                            triggerOnload(loader, load, err, true);
                        throw err;
                    }
                );
                return (load.E = execPromise);
            }
            // (should be a promise, but a minify optimization to leave out Promise.resolve)
            load.C = load.n;
            load.L = load.I = undefined;
        } catch (err) {
            load.er = err as Error;
            throw err;
        } finally {
            if (!process.env.SYSTEM_PRODUCTION)
                triggerOnload(loader, load, load.er!, true);
        }
    }
}
